跳转至

Git 子模块作为包管理器

文章背景与核心概要

本文从包管理器的视角审视了 Git 子模块(Submodules)。虽然子模块在功能上与包管理器相似——利用 Git 链接(Gitlinks)作为锁文件(lockfiles),.gitmodules 作为清单文件(manifests)——但其实际用户体验却充斥着各种阻碍、工具冲突(例如与 git worktree 的冲突)、复杂的本地存储布局以及历史安全漏洞。

文章详细剖析了子模块在版本解析(Resolution)、安装(Installation)、存储(Storage)、更新(Updating)以及安全性(Security)方面的工作机制,并将它们与现代包管理器完善的抽象设计进行了鲜明的对比。


1. 解析 (Resolution)

The gitlink records only which commit to check out, so .gitmodules carries a url per submodule and update clones from there, which is the only resolution mechanism. If the upstream repository is renamed, transferred to a different host, or taken private, every downstream pin breaks, even though the SHA is unchanged and the objects still exist in every clone that already has them. The manifest hard-codes a host because git has no lookup from a commit ID to servers that hold it.

gitlink 仅记录要检出哪一个提交(commit),因此 .gitmodules 为每个子模块携带一个 url,且 update 操作会从该地址进行克隆,这是唯一的解析机制。如果上游仓库被重命名、转移到其他主机或变为私有仓库,所有下游的固定引用(pin)都会失效,即便 SHA 值未发生改变,且对象依然存在于每个已经拥有它的克隆中。该清单文件硬编码了主机地址,因为 git 无法通过提交 ID 反向查找持有它的服务器。

Git also copies each URL into the superproject’s .git/config the first time git submodule init runs, under submodule.<name>.url, and later commands read it from there, ignoring .gitmodules. Editing the committed .gitmodules to point at a mirror or a fork leaves an already-initialised clone unchanged until git submodule sync copies the new value across.

当首次运行 git submodule init 时,Git 还会将每个 URL 复制到超级项目(superproject)的 .git/config 中(位于 submodule.<name>.url 下),后续的命令将直接从该处读取,而忽略 .gitmodules。修改已提交的 .gitmodules 以指向镜像或分叉(fork)仓库,将不会对已初始化的克隆产生任何影响,直到运行 git submodule sync 将新值同步过去。

The usual workaround in CI is git’s global url.<base>.insteadOf config, which rewrites any URL with a matching prefix before fetching, submodule URLs included. The common cases are rewriting https://github.com/ to git@github.com: so an SSH deploy key applies, or redirecting an internal hostname to a mirror.

在 CI 环境中,通常的变通方法是使用 Git 的全局 url.<base>.insteadOf 配置。它会在抓取(fetch)之前重写任何具有匹配前缀的 URL,包括子模块的 URL。常见场景包括将 https://github.com/ 重写为 git@github.com: 以应用 SSH 部署密钥,或者将内部主机名重定向到镜像。


2. 安装 (Installation)

A plain git clone writes the gitlink into the index so the submodule directory exists, and leaves it empty until git submodule update --init runs or the clone was made with --recurse-submodules. The submodule.recurse config setting makes checkout, fetch, pull, grep and several other commands recurse automatically, and it defaults to off.

普通的 git clone 会将 gitlink 写入索引中,使子模块目录得以存在,但在运行 git submodule update --init 或在克隆时带上 --recurse-submodules 参数之前,该目录一直为空。配置项 submodule.recurse 可以让 checkoutfetchpullgrep 以及其他几个命令自动递归执行,该配置默认处于关闭状态。

By default update checks out the gitlink commit, detached, and two independent flags modify that:

  • --init: copy any missing .gitmodules entries into .git/config first, required on first run and a no-op after
  • --remote: check out the tip of the submodule’s configured remote-tracking branch instead of the gitlink commit (the remote’s HEAD if submodule.<name>.branch is unset)

默认情况下,update 会以分离头指针(detached)状态检出 gitlink 对应的提交,有两个独立的标志可以修改这一行为:

  • --init:首先将任何缺失的 .gitmodules 条目复制到 .git/config 中,这在首次运行时是必需的,之后则为空操作。
  • --remote:检出子模块配置的远程跟踪分支的头部(tip),而不是 gitlink 的提交(如果未设置 submodule.<name>.branch,则为远程的 HEAD)。

The command reference documents both, though the name update conflates “install what’s pinned” with “update to latest”. Switching branches in the superproject changes the gitlink in the index and leaves the submodule’s working tree wherever it already was, so git status immediately shows the submodule as modified. Passing --recurse-submodules to checkout, or setting submodule.recurse, brings the submodule working tree along with the branch switch. The Rust project’s account of moving compiler subprojects off submodules lists this cluster from experience: checkouts left empty or on the wrong commit after clone, unrelated submodule bumps landing in pull requests because a branch switch left the submodule dirty, and custom logic in the bootstrap build tool to check each submodule out to the right commit before building.

命令参考文档对这两者都有记载,尽管 update 这个名字将“安装已固定的版本”与“更新到最新版本”的概念混淆了。在超级项目中切换分支会改变索引中的 gitlink,但子模块的工作树会停留在它原本所在的位置,因此 git status 会立即显示子模块已被修改。将 --recurse-submodules 传递给 checkout,或者设置 submodule.recurse,可以在切换分支时连同子模块的工作树一起切换。Rust 项目在关于将编译器子项目移出子模块的复盘文章中列举了这些由经验教训汇聚成的问题:克隆后检出为空或处于错误提交、由于分支切换导致子模块处于脏状态(dirty)而使不相关的子模块升级混入拉取请求(PR),以及在 bootstrap 构建工具中编写自定义逻辑以便在构建前将每个子模块检出到正确提交的繁琐操作。


3. 存储 (Storage)

A submodule’s git directory is stored under the superproject’s $GIT_DIR/modules/<name>/, with a .git file in the submodule’s working tree containing a gitdir: pointer back to it and a core.worktree setting pointing the other way. git submodule absorbgitdirs migrates older clones that still have a nested .git/ directory. Each entry under modules/ is a git directory with its own refs, HEAD, index, config, hooks, and by default its own object store. Removing a submodule is correspondingly spread across three places: git rm <path> drops the gitlink and the .gitmodules entry, git submodule deinit <path> clears the working tree and the .git/config entry, and the absorbed $GIT_DIR/modules/<name> directory that both leave behind is documented as a manual rm -rf.

子模块的 git 目录存储在超级项目的 $GIT_DIR/modules/<name>/ 下,子模块工作树中包含一个 .git 文件,其中含有指向该目录的 gitdir: 指针,反方向则由 core.worktree 设置指向工作树。git submodule absorbgitdirs 用于将那些仍然带有嵌套 .git/ 目录的旧克隆迁移过来。modules/ 下的每个条目都是一个独立的 git 目录,拥有自己的 refs、HEAD、索引、配置、钩子(hooks),并且默认拥有自己的对象存储。相应地,移除子模块的操作分散在三个地方:git rm <path> 会丢弃 gitlink 和 .gitmodules 条目,git submodule deinit <path> 会清除工作树和 .git/config 条目,而两者遗留下来已被吸收的 $GIT_DIR/modules/<name> 目录则需要按照官方文档手动执行 rm -rf 来删除。

Worktrees and submodules collide over this layout because a linked worktree shares the superproject’s $GIT_DIR but has its own working tree, HEAD, and index under $GIT_DIR/worktrees/<id>/. Put two worktrees on different superproject branches and they reference the same submodule at two different commits. Each needs its own submodule checkout and index, tied to storage that’s partly per-worktree and partly shared. worktree remove requires the override rather than checking whether that state is disposable, and worktree move refuses because the pointer-file rewrite it would need is unimplemented.

工作树(worktrees)与子模块在这个存储布局上产生了冲突,因为链接的工作树共享超级项目的 $GIT_DIR,但在 $GIT_DIR/worktrees/<id>/ 下拥有其自己的工作树、HEAD 和索引。当把两个工作树放在超级项目的不同分支上时,它们会在两个不同的提交上引用同一个子模块。每个工作树都需要自己的子模块检出和索引,并绑定到一部分属于每个工作树专属、一部分共享的存储上。worktree remove 需要强制覆盖(override)参数,而不是去检查该状态是否是一次性的;而 worktree move 则会直接拒绝执行,因为其所需的指针文件重写功能尚未实现。

Xavier Morel asked on the git list this March whether a submodule checkout could itself be a worktree of an existing shared clone, having found bare repositories plus worktrees worked well for a set of related projects but that adding submodules on top always cloned fresh. An RFC and a three-patch series proposing --recurse-submodules for git worktree add followed in April, giving each linked worktree its own submodule git directory under $GIT_COMMON_DIR/worktrees/<id>/modules/ and sharing the object storage between them by hardlink.

Xavier Morel 在今年三月的 git 邮件列表上询问,子模块的检出本身是否可以是一组现有共享克隆的工作树——他发现裸仓库(bare repositories)加上工作树非常适合一组相关项目,但在其上添加子模块时总是会进行全新克隆。随后在四月份,出现了一份 RFC 以及一个包含三个补丁的系列,提议为 git worktree add 引入 --recurse-submodules,这使得每个链接的工作树都在 $GIT_COMMON_DIR/worktrees/<id>/modules/ 下拥有自己的子模块 git 目录,并通过硬链接(hardlink)在它们之间共享对象存储。

The same multiplication happens in a single-worktree clone when two submodules both depend on a third repository. Each path in the superproject gets its own modules/ entry, its own object store unless alternates are configured by hand, and its own gitlink. The two pins can point at different commits of the same repository, and git treats them as unrelated checkouts. Package managers with a shared cache (cargo’s registry cache, pnpm’s content-addressable store, the Go module cache) store the bytes once and check them out per location.

在单工作树的克隆中,当两个子模块都依赖于第三个仓库时,也会发生同样的资源膨胀。超级项目中的每个路径都会获得自己的 modules/ 条目、自己的对象存储(除非手动配置了 alternates)以及自己的 gitlink。这两个固定引用可以指向同一个仓库的不同提交,而 git 会将它们视为毫无关系的检出。拥有共享缓存的包管理器(例如 Cargo 的注册表缓存、pnpm 的内容寻址存储Go 模块缓存)只会存储一次字节数据,并按需在各个位置进行检出。


4. 更新 (Updating)

The gitlink holds one commit SHA, so moving a submodule forward means entering it, fetching, checking out the new commit, leaving, and git add <path> in the superproject to record the new gitlink. git submodule update --remote fetches the configured branch’s tip and checks that out instead of the recorded gitlink, and committing the result in the superproject is what moves the pin. .gitmodules can name a branch per submodule for --remote and the update bots to follow. A plain update ignores that field and checks out the gitlink SHA regardless. There is no syntax for a version range, a tag pattern, or a minimum commit, so the manifest’s only floating reference is a branch name and the gitlink is the only pin.

gitlink 仅保存一个提交 SHA,因此推进子模块意味着:进入子模块目录、获取最新数据、检出新提交、离开该目录,并在超级项目中执行 git add <path> 来记录新的 gitlink。git submodule update --remote 会抓取所配置分支的头部,并将其检出以替代记录的 gitlink,最后在超级项目中提交该结果才能真正移动固定引用。.gitmodules 可以为每个子模块指定一个 branch--remote 以及更新机器人(update bots)遵循。普通的 update 会忽略该字段,并无条件检出 gitlink 对应的 SHA。这里没有任何用于版本范围、标签模式(tag pattern)或最小提交的语法,因此清单文件中唯一的浮动引用就是分支名称,而 gitlink 则是唯一的固定引用。

Dependabot and Renovate can both open pull requests bumping a gitlink. Dependabot’s gitsubmodule ecosystem proposes a new gitlink SHA when the submodule’s configured branch moves, and Renovate’s git-submodules manager does the same, shipping disabled by default; both follow branch tips because a branch name is the only reference the manifest exposes.

Dependabot 和 Renovate 都可以开启升级 gitlink 的拉取请求。Dependabot 的 gitsubmodule 生态系统在子模块配置的分支发生变动时会提议一个新的 gitlink SHA,Renovate 的 git-submodules 管理器也做同样的事情(默认情况下该功能是禁用的);这两者都是通过追踪分支头部来实现的,因为分支名称是清单文件公开的唯一引用。


5. 安全性 (Security)

.gitmodules is committed to the repository, so a hostile upstream controls its contents, and git parses it during clone --recurse-submodules before the user has seen any of the fetched files, a combination that has produced remote code execution repeatedly. CVE-2018-11235 used ../ in a submodule’s name so its git directory, hooks included, was written outside $GIT_DIR/modules/ and a post-checkout hook ran during clone. In CVE-2018-17456 the submodule URL began with -, so the child git clone parsed it as an option, the class of bug git’s --end-of-options delimiter defends against. CVE-2022-39253 was a disclosure bug: a symlink in a submodule’s object directory made a local-transport clone copy arbitrary files from the victim’s disk. The fix changed the protocol.file.allow default to user, so local-path submodules now need an explicit opt-in. CVE-2024-32002 combined a symlink with a case-insensitive filesystem to write a hook into .git/ during recursive clone. I covered the broader pattern of package-manager checkout paths as an attack surface in the CWE field guide.

.gitmodules 是被提交到仓库中的,因此恶意的上游可以控制其内容,而 git 会在用户看到任何已获取的文件之前,于执行 clone --recurse-submodules 期间对其进行解析——正是这种组合屡次引发了远程代码执行漏洞。CVE-2018-11235 利用子模块名称中的 ../,使得其 git 目录(包括钩子)被写入到 $GIT_DIR/modules/ 之外,并在克隆期间触发了 post-checkout 钩子。在 CVE-2018-17456 中,子模块的 URL 以 - 开头,导致子进程的 git clone 将其解析为一个选项(即 git 的 --end-of-options 分隔符专门用于防御的那类漏洞)。CVE-2022-39253 则是一个信息泄露漏洞:子模块对象目录中的符号链接(symlink)使得本地传输的克隆操作能够从受害者的磁盘中复制任意文件。该漏洞的修复将 protocol.file.allow 的默认值更改为了 user,因此现在本地路径子模块需要显式选择启用。CVE-2024-32002 将符号链接与不区分大小写的文件系统相结合,在递归克隆期间将钩子写入了 .git/ 中。我在CWE 实战指南中探讨了作为攻击面的包管理器检出路径的更广泛模式。


6. 抽象 (Abstraction)

Submodules expose git’s internals directly: object IDs as the pin, detached HEADs after update, the $GIT_DIR/modules/ layout, transport URLs in the manifest. A package manager wraps the equivalents behind a manifest format, a resolver, and a local cache; submodules surface them raw.

子模块直接暴露了 git 的内部机制:作为固定引用的对象 ID、更新后分离的 HEAD、$GIT_DIR/modules/ 布局、清单文件中的传输 URL。而包管理器则将这些等效功能包装在清单格式、解析器和本地缓存的背后;子模块则直接将它们赤裸裸地呈现出来。

Most of the gaps map to things package managers already solved: a shared object cache, recursing into dependencies by default on clone and checkout, a single lifecycle for adding and removing a dependency, range constraints in the manifest. The April patch series adding --recurse-submodules to git worktree add tackles one instance of the storage problem, giving each worktree its own submodule checkout over hardlinked shared storage. Resolution is the harder one: a commit SHA is a host-independent identity for the object, and the URL in .gitmodules is git’s only mapping from that identity to a server that holds it.

绝大多数的差距都对应着包管理器早已解决的问题:共享对象缓存、在克隆和检出时默认递归进入依赖项、添加和移除依赖项的单一生命周期、清单文件中的版本范围约束。为 git worktree add 增加 --recurse-submodules四月补丁系列解决了存储问题的一个实例,使每个工作树都能在基于硬链接的共享存储之上拥有自己的子模块检出。而解析问题则更加棘手:提交 SHA 是对象的独立于主机的身份标识,而 .gitmodules 中的 URL 则是 git 将该身份映射到持有它的服务器上的唯一途径。